#!/usr/bin/env python3
# -*- coding: utf-8 -*-

# Copyright (c) 2024 - 2025 Detlev Offenbach <detlev@die-offenbachs.de>
#
# This is the install script for the eric-ide server. It may be used
# to just install the server for remote editing and debugging.
#

"""
Installation script for the eric-ide server.
"""

import argparse
import compileall
import contextlib
import fnmatch
import importlib
import io
import json
import os
import re
import shutil
import subprocess  # secok
import sys
import sysconfig

# Define the globals.
currDir = os.getcwd()
scriptsDir = None
modDir = None
pyModDir = None
distDir = None
installPackage = "eric7"
doCleanup = True
doCompile = True
doDepChecks = True
proxy = None
sourceDir = "eric"
eric7SourceDir = ""


def exit(rcode=0):
    """
    Exit the install script.

    @param rcode result code to report back
    @type int
    """
    global currDir

    if sys.platform.startswith("win"):
        with contextlib.suppress(EOFError):
            input("Press enter to continue...")  # secok

    os.chdir(currDir)

    sys.exit(rcode)


def initGlobals():
    """
    Module function to set the values of globals that need more than a
    simple assignment.
    """
    global modDir, pyModDir, scriptsDir

    # determine the platform scheme
    if sys.platform.startswith(("win", "cygwin")):
        scheme = "nt_user"
    elif sys.platform == "darwin":
        scheme = "osx_framework_user"
    else:
        scheme = "posix_user"

    # determine modules directory
    modDir = sysconfig.get_path("platlib")
    if not os.access(modDir, os.W_OK):
        # can't write to the standard path, use the 'user' path instead
        modDir = sysconfig.get_path("platlib", scheme)
    pyModDir = modDir

    # determine the scripts directory
    scriptsDir = sysconfig.get_path("scripts")
    if not os.access(scriptsDir, os.W_OK):
        # can't write to the standard path, use the 'user' path instead
        scriptsDir = sysconfig.get_path("scripts", scheme)


def copyToFile(name, text):
    """
    Copy a string to a file.

    @param name name of the file
    @type str
    @param text contents to copy to the file
    @type str
    """
    with open(name, "w") as f:
        f.write(text)


def copyTree(src, dst, filters, excludeDirs=None, excludePatterns=None):
    """
    Copy files of a directory tree.

    @param src name of the source directory
    @type str
    @param dst name of the destination directory
    @type str
    @param filters list of filter pattern determining the files to be copied
    @type list of str
    @param excludeDirs list of (sub)directories to exclude from copying
    @type list of str
    @param excludePatterns list of filter pattern determining the files to
        be skipped
    @type str
    """
    if excludeDirs is None:
        excludeDirs = []
    if excludePatterns is None:
        excludePatterns = []
    try:
        names = os.listdir(src)
    except OSError:
        # ignore missing directories
        return

    for name in names:
        skipIt = False
        for excludePattern in excludePatterns:
            if fnmatch.fnmatch(name, excludePattern):
                skipIt = True
                break
        if not skipIt:
            srcname = os.path.join(src, name)
            dstname = os.path.join(dst, name)
            for fileFilter in filters:
                if fnmatch.fnmatch(srcname, fileFilter):
                    if not os.path.isdir(dst):
                        os.makedirs(dst)
                    shutil.copy2(srcname, dstname)
                    os.chmod(dstname, 0o644)
                    break
            else:
                if os.path.isdir(srcname) and srcname not in excludeDirs:
                    copyTree(srcname, dstname, filters, excludePatterns=excludePatterns)


def cleanupSource(dirName):
    """
    Cleanup the sources directory to get rid of leftover files
    and directories.

    @param dirName name of the directory to prune
    @type str
    """
    # step 1: delete the __pycache__ directory and all *.pyc files
    if os.path.exists(os.path.join(dirName, "__pycache__")):
        shutil.rmtree(os.path.join(dirName, "__pycache__"))
    for name in [f for f in os.listdir(dirName) if fnmatch.fnmatch(f, "*.pyc")]:
        os.remove(os.path.join(dirName, name))

    # step 2: descent into subdirectories and delete them if empty
    for name in os.listdir(dirName):
        name = os.path.join(dirName, name)
        if os.path.isdir(name):
            cleanupSource(name)
            if len(os.listdir(name)) == 0:
                os.rmdir(name)


def cleanUp():
    """
    Uninstall the old eric files.
    """
    global installPackage, pyModDir, scriptsDir

    try:
        # Cleanup the package directories
        dirname = os.path.join(pyModDir, installPackage)
        if os.path.exists(dirname):
            shutil.rmtree(dirname, ignore_errors=True)
    except OSError as msg:
        sys.stderr.write("Error: {0}\nTry install with admin rights.\n".format(msg))
        exit(7)

    # Remove the wrapper scripts
    rem_wnames = ["eric7_server"]
    try:
        for rem_wname in rem_wnames:
            for rwname in wrapperNames(scriptsDir, rem_wname):
                if os.path.exists(rwname):
                    os.remove(rwname)
    except OSError as msg:
        sys.stderr.write("Error: {0}\nTry install with admin rights.\n".format(msg))
        exit(7)


def wrapperNames(dname, wfile):
    """
    Create the platform specific names for the wrapper script.

    @param dname name of the directory to place the wrapper into
    @type str
    @param wfile basename (without extension) of the wrapper script
    @type str
    @return list containing the names of the wrapper scripts
    @rtype list of str
    """
    wnames = (
        [dname + "\\" + wfile + ".cmd", dname + "\\" + wfile + ".bat"]
        if sys.platform.startswith(("win", "cygwin"))
        else [dname + "/" + wfile]
    )

    return wnames


def createPyWrapper(pydir, wfile, saveDir):
    """
    Create an executable wrapper for a Python script.

    @param pydir name of the directory where the Python script will
        eventually be installed
    @type str
    @param wfile basename of the wrapper
    @type str
    @param saveDir directory to save the file into
    @type str
    @return the platform specific name of the wrapper
    @rtype str
    """
    # all kinds of Windows systems
    if sys.platform.startswith(("win", "cygwin")):
        wname = wfile + ".cmd"
        wrapper = """@"{0}" "{1}\\{2}.py" %1 %2 %3 %4 %5 %6 %7 %8 %9\n""".format(
            sys.executable, pydir, wfile
        )

    # Mac OS X
    elif sys.platform == "darwin":
        major = sys.version_info.major
        pyexec = "{0}/bin/python{1}".format(sys.exec_prefix, major)
        wname = wfile
        wrapper = (
            """#!/bin/sh\n"""
            """\n"""
            """exec "{0}" "{1}/{2}.py" "$@"\n""".format(pyexec, pydir, wfile)
        )

    # *nix systems
    else:
        wname = wfile
        wrapper = (
            """#!/bin/sh\n"""
            """\n"""
            """exec "{0}" "{1}/{2}.py" "$@"\n""".format(sys.executable, pydir, wfile)
        )

    wname = os.path.join(saveDir, wname)
    copyToFile(wname, wrapper)
    os.chmod(wname, 0o755)  # secok

    return wname


def shutilCopy(src, dst, perm=0o644):
    """
    Wrapper function around shutil.copy() to ensure the permissions.

    @param src source file name
    @type str
    @param dst destination file name or directory name
    @type str
    @param perm permissions to be set
    @type int
    """
    shutil.copy(src, dst)
    if os.path.isdir(dst):
        dst = os.path.join(dst, os.path.basename(src))
    os.chmod(dst, perm)


def pipInstall(packageName, message, force=True):
    """
    Install the given package via pip.

    @param packageName name of the package to be installed
    @type str
    @param message message to be shown to the user
    @type str
    @param force flag indicating to perform the installation
        without asking the user
    @type bool
    @return flag indicating a successful installation
    @rtype bool
    """
    global proxy

    ok = False
    if force:
        answer = "y"
    else:
        print(
            "{0}\nShall '{1}' be installed using pip? (Y/n)".format(
                message, packageName
            ),
            end=" ",
        )
        answer = input()  # secok
    if answer in ("", "Y", "y"):
        args = [
            sys.executable,
            "-m",
            "pip",
            "install",
            "--prefer-binary",
            "--upgrade",
        ]
        if proxy:
            args.append(f"--proxy={proxy}")
        args.append(packageName)
        exitCode = subprocess.run(args).returncode  # secok
        ok = exitCode == 0

    return ok


def isPipOutdated():
    """
    Check, if pip is outdated.

    @return flag indicating an outdated pip
    @rtype bool
    """
    global proxy

    try:
        args = [
            sys.executable,
            "-m",
            "pip",
            "list",
            "--outdated",
            "--format=json",
        ]
        if proxy:
            args.append(f"--proxy={proxy}")
        pipOut = (
            subprocess.run(  # secok
                args,
                check=True,
                capture_output=True,
                text=True,
            )
            .stdout.strip()
            .splitlines()[0]
        )
        # only the first line contains the JSON data
    except (OSError, subprocess.CalledProcessError):
        pipOut = "[]"  # default empty list
    try:
        jsonList = json.loads(pipOut)
    except Exception:
        jsonList = []
    for package in jsonList:
        if isinstance(package, dict) and package["name"] == "pip":
            print(
                "'pip' is outdated (installed {0}, available {1})".format(
                    package["version"], package["latest_version"]
                )
            )
            return True

    return False


def updatePip():
    """
    Update the installed pip package.
    """
    global proxy

    print("Shall 'pip' be updated (recommended)? (Y/n)", end=" ")
    answer = input()  # secok
    if answer in ("", "Y", "y"):
        args = [
            sys.executable,
            "-m",
            "pip",
            "install",
            "--upgrade",
        ]
        if proxy:
            args.append(f"--proxy={proxy}")
        args.append("pip")
        subprocess.run(args)  # secok


def doDependancyChecks():
    """
    Perform some dependency checks.
    """
    try:
        isSudo = os.getuid() == 0 and sys.platform != "darwin"
        # disregard sudo installs on macOS
    except AttributeError:
        isSudo = False

    print("Checking dependencies")

    # update pip first even if we don't need to install anything
    if not isSudo and isPipOutdated():
        updatePip()
        print("\n")

    # perform dependency checks
    if sys.version_info < (3, 9, 0) or sys.version_info >= (3, 15, 0):
        print("Sorry, you must have Python 3.9.0 or higher, but less 3.15.0.")
        print("Yours is {0}.".format(".".join(str(v) for v in sys.version_info[:3])))
        exit(5)

    requiredModulesList = {
        # key is pip project name
        # value is tuple of package name, pip install constraint
        "coverage": ("coverage", ">=6.5.0"),
        "EditorConfig": ("editorconfig", ""),
    }

    # check required modules
    print("Required Packages")
    print("-----------------")
    requiredMissing = False
    for requiredPackage in sorted(requiredModulesList):
        try:
            importlib.import_module(requiredModulesList[requiredPackage][0])
            print("Found", requiredPackage)
        except ImportError as err:
            if isSudo:
                print("Required '{0}' could not be detected.".format(requiredPackage))
                requiredMissing = True
            else:
                pipInstall(
                    requiredPackage + requiredModulesList[requiredPackage][1],
                    "Required '{0}' could not be detected.{1}".format(
                        requiredPackage, "\nError: {0}".format(err)
                    ),
                    force=True,
                )
    if requiredMissing:
        print("Some required packages are missing and could not be installed.")
        print("Install them manually.")

    print()
    print("All dependencies ok.")
    print()


def installEricServer():
    """
    Actually perform the installation steps.

    @return result code
    @rtype int
    """
    global distDir, sourceDir, modDir, scriptsDir

    # set install prefix, if not None
    targetDir = (
        os.path.normpath(os.path.join(distDir, installPackage))
        if distDir
        else os.path.join(modDir, installPackage)
    )
    if not os.path.isdir(targetDir):
        os.makedirs(targetDir)

    # Create the platform specific wrapper.
    tmpScriptsDir = "install_scripts"
    if not os.path.isdir(tmpScriptsDir):
        os.mkdir(tmpScriptsDir)
    wrapper = createPyWrapper(targetDir, "eric7_server", tmpScriptsDir)

    try:
        # Install the files
        # copy the various parts of eric-ide server
        for package in ("DebugClients", "RemoteServer"):
            copyTree(
                os.path.join(eric7SourceDir, package),
                os.path.join(targetDir, package),
                ["*.py", "*.pyc", "*.pyo", "*.pyw"],
            )
        # copy the needed parts of SystemUtilities
        os.makedirs(os.path.join(targetDir, "SystemUtilities"))
        for module in ("__init__.py", "FileSystemUtilities.py", "OSUtilities.py"):
            shutilCopy(
                os.path.join(eric7SourceDir, "SystemUtilities", module),
                os.path.join(targetDir, "SystemUtilities"),
            )

        # copy the top level files
        for module in ("__init__.py", "__version__.py", "eric7_server.py"):
            shutilCopy(os.path.join(eric7SourceDir, module), targetDir)

        # copy the license and README files
        for infoFile in ("LICENSE.txt", "README-server.md"):
            shutilCopy(os.path.join(sourceDir, "docs", infoFile), targetDir)

        # copy the wrapper
        shutilCopy(wrapper, scriptsDir, perm=0o755)
        shutil.rmtree(tmpScriptsDir)

    except OSError as msg:
        sys.stderr.write("\nError: {0}\nTry install with admin rights.\n".format(msg))
        return 7

    return 0


def createArgumentParser():
    """
    Function to create an argument parser.

    @return created argument parser object
    @rtype argparse.ArgumentParser
    """
    parser = argparse.ArgumentParser(
        description="Install eric-ide server from the source code tree."
    )

    parser.add_argument(
        "-d",
        metavar="dir",
        default=modDir,
        help="directory where eric-ide server files will be installed"
        " (default: {0})".format(modDir),
    )
    parser.add_argument(
        "-b",
        metavar="dir",
        default=scriptsDir,
        help="directory where the binaries will be installed (default: {0})".format(
            scriptsDir
        ),
    )
    if not sys.platform.startswith(("win", "cygwin")):
        parser.add_argument(
            "-i",
            metavar="dir",
            default=distDir,
            help="temporary install prefix (default: {0})".format(distDir),
        )
    parser.add_argument(
        "-c",
        action="store_false",
        help="don't cleanup old installation first",
    )
    parser.add_argument(
        "-z",
        action="store_false",
        help="don't compile the installed Python files",
    )
    parser.add_argument(
        "-x",
        action="store_false",
        help="don't perform dependency checks (use on your own risk)",
    )
    parser.add_argument(
        "--proxy",
        default=None,
        metavar="url",
        help="HTTP proxy url will be used with pip (default: no proxy used)",
    )

    return parser


def main(argv):
    """
    The main function of the script.

    @param argv the list of command line arguments
    @type list of str
    """
    global modDir, doCleanup, doCompile, doDepChecks, distDir, scriptsDir, proxy
    global sourceDir, eric7SourceDir

    if sys.version_info < (3, 9, 0) or sys.version_info >= (4, 0, 0):
        print("Sorry, the eric-ide serverr requires Python 3.9 or better for running.")
        exit(5)

    if os.path.dirname(argv[0]):
        os.chdir(os.path.dirname(argv[0]))

    initGlobals()

    parser = createArgumentParser()
    args = parser.parse_args()

    modDir = args.d
    scriptsDir = args.b
    doDepChecks = args.x
    doCleanup = args.c
    doCompile = args.z
    proxy = args.proxy
    if not sys.platform.startswith(("win", "cygwin")) and args.i:
        distDir = os.path.normpath(args.i)

    # check dependencies
    if doDepChecks:
        doDependancyChecks()

    installFromSource = not os.path.isdir(sourceDir)
    if installFromSource:
        sourceDir = os.path.abspath("..")

    eric7SourceDir = (
        os.path.join(sourceDir, "eric7")
        if os.path.exists(os.path.join(sourceDir, "eric7"))
        else os.path.join(sourceDir, "src", "eric7")
    )

    # cleanup source if installing from source
    if installFromSource:
        print("Cleaning up source ...", end="", flush=True)
        cleanupSource(os.path.join(eric7SourceDir, "DebugClients"))
        print(" Done")

    # cleanup old installation
    try:
        if doCleanup:
            print("Cleaning up old installation ...", end="", flush=True)
            if distDir:
                shutil.rmtree(distDir, ignore_errors=True)
            else:
                cleanUp()
            print(" Done")
    except OSError as msg:
        sys.stderr.write("Error: {0}\nTry install as root.\n".format(msg))
        exit(7)

    if doCompile:
        print("Compiling source files ...", end="", flush=True)
        skipRe = re.compile(r"DebugClients[\\/]Python[\\/]")
        sys.stdout = io.StringIO()
        if distDir:
            compileall.compile_dir(
                os.path.join(eric7SourceDir, "DebugClients"),
                ddir=os.path.join(distDir, modDir, installPackage),
                rx=skipRe,
                quiet=True,
            )
        else:
            compileall.compile_dir(
                os.path.join(eric7SourceDir, "DebugClients"),
                ddir=os.path.join(modDir, installPackage),
                rx=skipRe,
                quiet=True,
            )
        sys.stdout = sys.__stdout__
        print(" Done")

    print("Installing eric-ide server ...", end="", flush=True)
    res = installEricServer()
    print(" Done")

    print("Installation complete.")
    print()

    exit(res)


if __name__ == "__main__":
    try:
        main(sys.argv)
    except SystemExit:
        raise
    except Exception:
        print(
            """An internal error occured.  Please report all the output"""
            """ of the program,\nincluding the following traceback, to"""
            """ eric-bugs@eric-ide.python-projects.org.\n"""
        )
        raise

#
# eflag: noqa = M-801
